Skip to content

第 7 章 Spring Boot 安全管理 ​

写给新手的话:

前面我们学了怎么写接口、怎么操作数据库、怎么用缓存。但是有个问题:谁都能访问你的接口吗?

显然不行。用户要登录才能用,不同的用户有不同的权限,管理员能做的事普通用户不能做。

这就是安全管理要解决的问题:认证(你是谁)和 授权(你能做什么)。

Spring Boot 里最常用的安全框架就是 Spring Security,功能强大,跟 Spring 生态无缝集成。

学习建议:

  • Spring Security 刚开始学可能觉得有点复杂,概念比较多
  • 先把核心概念搞清楚:认证、授权、UserDetails、UserDetailsService
  • 多动手,跟着例子敲代码,跑起来就明白了
  • 不用一下子全学会,先掌握常用的,其他的用到再查

准备好了吗?我们开始吧!


7.1 先搞明白:安全管理那些事儿 ​

7.1.1 为什么需要安全管理 ​

想象一下:

  • 你做了一个图书管理系统
  • 任何人都能访问,不用登录
  • 任何人都能删书、改价格
  • 这能行吗?肯定不行!

所以我们需要安全管理,主要解决两个问题:

  1. 认证(Authentication):你是谁?

    • 验证用户身份,比如用户名密码登录
    • 确认你是这个系统的合法用户
  2. 授权(Authorization):你能做什么?

    • 登录之后,你有哪些权限
    • 普通用户只能看书,管理员才能改书
    • 不同角色有不同的操作权限

简单记:

  • 认证 = 证明你是谁(登录)
  • 授权 = 决定你能干嘛(权限)

7.1.2 常见的安全框架 ​

Java 生态里常见的安全框架:

框架特点适用场景
Spring SecuritySpring 官方,功能强大,生态好Spring 项目首选
Apache Shiro轻量,简单,灵活小项目、非 Spring 项目
Sa-Token国产,简单易用,功能全新项目可以试试

Spring Security 的优势:

  • Spring 官方出品,跟 Spring Boot 无缝集成
  • 功能非常全面:认证、授权、攻击防护、OAuth2、JWT 等
  • 社区活跃,资料多
  • 企业用得很多

Shiro 的优势:

  • 轻量,简单,上手快
  • 不依赖 Spring,什么项目都能用
  • 概念少,容易理解

现在 Spring Boot 项目,一般都用 Spring Security。 这一章我们就学 Spring Security。

7.1.3 Spring Security 是什么 ​

Spring Security 是 Spring 家族的安全框架,提供了一套完整的安全解决方案。

主要功能:

  • 认证(用户名密码登录、手机号登录、第三方登录等)
  • 授权(URL 权限、方法权限、数据权限)
  • 密码加密
  • 会话管理
  • CSRF 防护
  • 记住我(Remember Me)
  • 退出登录
  • OAuth2.0 / SSO
  • 等等...

Spring Security 功能非常强大,也比较复杂。 这一章我们学最核心、最常用的部分:认证和授权。

7.1.4 Spring Security 的核心思想 ​

Spring Security 的核心是过滤器链(Filter Chain)。

打个比方:

  • 请求就像一个人要进一栋大楼
  • 大楼门口有好几道关卡(过滤器)
  • 每道关卡检查一样东西:有没有登录、有没有权限、有没有 CSRF token...
  • 所有关卡都通过了,才能进到大楼里(访问你的接口)
  • 哪一关没过,就被拦下来了
请求 → 过滤器1 → 过滤器2 → 过滤器3 → ... → Controller
       (认证)   (授权)   (CSRF)

Spring Security 就是通过一系列过滤器来实现安全控制的。

不用太深入原理,先知道有这么个东西就行。 后面用多了自然就理解了。


7.2 快速入门:第一个 Spring Security 项目 ​

我们来动手搭一个最简单的 Spring Security 项目,感受一下。

7.2.1 创建项目 ​

步骤 1:创建 Spring Boot 项目

选择依赖:

  • Spring Web
  • Spring Security
  • Thymeleaf(后面做页面用)

pom.xml 关键依赖:

xml
<!-- Spring Security -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- Web -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Thymeleaf -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

步骤 2:写一个测试接口

java
@Controller
public class HelloController {

    @RequestMapping("/hello")
    @ResponseBody
    public String hello() {
        return "Hello Spring Security!";
    }
}

步骤 3:启动项目

启动项目,你会在控制台看到一行类似这样的输出:

Using generated security password: 3e4d5f6g-7h8i-9j0k-1l2m-3n4o5p6q7r8s

这是 Spring Security 自动生成的默认密码。

步骤 4:访问测试

浏览器访问 http://localhost:8080/hello

你会发现...自动跳转到了一个登录页面!

这就是 Spring Security 的默认效果:加了依赖,所有接口就自动被保护了。

默认用户:

  • 用户名:user
  • 密码:控制台输出的那串(每次启动都不一样)

输入用户名密码,登录成功,就能看到 Hello 了。

是不是很神奇?什么都没配置,就有了登录功能。 这就是 Spring Boot 的魅力:自动配置,开箱即用。

7.2.2 内存用户配置 ​

默认的用户只有一个,密码还是随机的,肯定不够用。

我们来配置几个内存用户。

创建配置类:

java
@Configuration
public class SecurityConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        // 内存用户管理器
        InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
        
        // 创建用户 admin,密码 123456,角色 admin
        users.createUser(User.withUsername("admin")
                .password("{noop}123456")  // {noop} 表示不加密
                .roles("ADMIN")
                .build());
        
        // 创建用户 user,密码 123456,角色 user
        users.createUser(User.withUsername("user")
                .password("{noop}123456")
                .roles("USER")
                .build());
        
        return users;
    }
}

说明:

  • {noop} 表示密码不加密,明文存储。开发测试用,生产环境绝对不能这样!
  • .roles("ADMIN") 给用户设置角色
  • 角色会自动加上 ROLE_ 前缀,也就是 ROLE_ADMIN

测试一下:

重启项目,用 admin/123456 登录,看看能不能成功。

内存用户适合开发测试,或者用户很少的场景。 实际项目肯定是从数据库查用户,后面会讲。

7.2.3 密码加密 ​

刚才用了 {noop} 明文密码,这很不安全。

生产环境密码一定要加密存储,Spring Security 推荐用 BCrypt 加密。

配置密码加密器:

java
@Configuration
public class SecurityConfig {

    /**
     * 密码加密器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
        
        // 密码用 BCrypt 加密
        users.createUser(User.withUsername("admin")
                .password(encoder.encode("123456"))
                .roles("ADMIN")
                .build());
        
        users.createUser(User.withUsername("user")
                .password(encoder.encode("123456"))
                .roles("USER")
                .build());
        
        return users;
    }
}

BCrypt 的特点:

  • 同一个密码,每次加密结果都不一样(加盐了)
  • 不可逆,不能解密
  • 验证的时候,把明文加密后跟密文比对

为什么不用 MD5?

  • MD5 可以被彩虹表破解,不安全
  • BCrypt 专门为密码设计,慢哈希,抗暴力破解
  • 现在推荐用 BCrypt 或者 Argon2

7.3 核心概念:搞懂这几个就够了 ​

Spring Security 概念比较多,先把这几个核心的搞明白。

7.3.1 UserDetails:用户信息 ​

UserDetails 是 Spring Security 定义的用户信息接口。

java
public interface UserDetails extends Serializable {
    // 用户的权限列表
    Collection<? extends GrantedAuthority> getAuthorities();
    
    // 密码
    String getPassword();
    
    // 用户名
    String getUsername();
    
    // 账号是否过期
    boolean isAccountNonExpired();
    
    // 账号是否锁定
    boolean isAccountNonLocked();
    
    // 密码是否过期
    boolean isCredentialsNonExpired();
    
    // 是否启用
    boolean isEnabled();
}

简单说:UserDetails 就是 Spring Security 认识的"用户"对象。

你自己的用户表(User 实体),Spring Security 不认识。 你需要把你的用户信息,转换成 Spring Security 认识的 UserDetails。

就像翻译一样:把你的用户,翻译成 Spring Security 能看懂的格式。

7.3.2 UserDetailsService:加载用户 ​

UserDetailsService 是加载用户信息的接口。

java
public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

只有一个方法:根据用户名加载用户信息,返回 UserDetails。

工作流程:

  1. 用户输入用户名密码登录
  2. Spring Security 调用 UserDetailsService.loadUserByUsername(username)
  3. 拿到 UserDetails(里面有正确的密码和权限)
  4. 跟用户输入的密码比对
  5. 比对成功 → 认证通过;失败 → 认证失败

UserDetailsService 就是个"查用户的服务"。 你实现这个接口,告诉 Spring Security 怎么从你的系统里查用户。

7.3.3 PasswordEncoder:密码加密 ​

PasswordEncoder 是密码加密接口。

java
public interface PasswordEncoder {
    // 加密
    String encode(CharSequence rawPassword);
    
    // 比对
    boolean matches(CharSequence rawPassword, String encodedPassword);
}

常用实现:

  • BCryptPasswordEncoder:推荐,最常用
  • NoOpPasswordEncoder:不加密,明文(测试用)
  • Pbkdf2PasswordEncoder:PBKDF2 算法
  • SCryptPasswordEncoder:SCrypt 算法

一般用 BCrypt 就行。

7.3.4 SecurityContextHolder:获取当前用户 ​

登录成功后,用户信息存在哪里?

存在 SecurityContextHolder 里,这是一个线程级别的存储。

java
// 获取当前认证信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

// 获取用户详情
UserDetails userDetails = (UserDetails) authentication.getPrincipal();

// 获取用户名
String username = userDetails.getUsername();

// 获取权限
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

// 是否已经认证
boolean isAuthenticated = authentication.isAuthenticated();

记住这个:想拿当前登录用户,就从 SecurityContextHolder 里拿。

7.3.5 认证 vs 授权 ​

再强调一下这两个概念,别搞混了:

概念英文回答的问题例子
认证Authentication你是谁?用户名密码登录
授权Authorization你能做什么?管理员才能删数据

顺序:先认证,后授权。

  • 先登录(认证),知道你是谁
  • 再判断你有没有权限(授权)访问这个资源

别把这两个词搞混了,面试经常问。


7.4 自定义登录页面 ​

默认的登录页面太丑了,实际项目肯定要自己做登录页。

7.4.1 写一个登录页面 ​

在 resources/templates/ 下创建 login.html:

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background: #f5f5f5;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
        }
        .login-box {
            background: white;
            padding: 30px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            width: 300px;
        }
        h2 {
            text-align: center;
            color: #333;
        }
        .form-group {
            margin-bottom: 15px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            color: #666;
        }
        input {
            width: 100%;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
        }
        button {
            width: 100%;
            padding: 10px;
            background: #4CAF50;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        button:hover {
            background: #45a049;
        }
        .error {
            color: red;
            text-align: center;
            margin-bottom: 10px;
        }
    </style>
</head>
<body>
    <div class="login-box">
        <h2>用户登录</h2>
        
        <!-- 登录失败提示 -->
        <div th:if="${param.error}" class="error">
            用户名或密码错误
        </div>
        
        <!-- 退出提示 -->
        <div th:if="${param.logout}" style="color: green; text-align: center; margin-bottom: 10px;">
            您已退出登录
        </div>
        
        <form action="/doLogin" method="post">
            <div class="form-group">
                <label>用户名</label>
                <input type="text" name="username" placeholder="请输入用户名">
            </div>
            <div class="form-group">
                <label>密码</label>
                <input type="password" name="password" placeholder="请输入密码">
            </div>
            <button type="submit">登录</button>
        </form>
    </div>
</body>
</html>

注意:

  • 表单的 action 是 /doLogin(后面配置)
  • 用户名输入框 name 是 username
  • 密码输入框 name 是 password
  • 登录失败会带 ?error 参数
  • 退出会带 ?logout 参数

7.4.2 配置自定义登录页 ​

java
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 授权配置
            .authorizeRequests()
                .antMatchers("/loginview", "/css/**", "/js/**", "/img/**").permitAll()  // 登录页和静态资源放行
                .anyRequest().authenticated()  // 其他都需要登录
            .and()
            
            // 登录配置
            .formLogin()
                .loginPage("/loginview")        // 自定义登录页
                .loginProcessingUrl("/doLogin") // 登录表单提交的地址
                .usernameParameter("username")  // 用户名参数名
                .passwordParameter("password")  // 密码参数名
                .defaultSuccessUrl("/")         // 登录成功后跳转的页面
                //.successForwardUrl("/")       // 登录成功后转发
                //.failureUrl("/loginview?error") // 登录失败跳转
            .and()
            
            // 退出登录
            .logout()
                .logoutUrl("/logout")           // 退出地址
                .logoutSuccessUrl("/loginview?logout")  // 退出成功跳转
            .and()
            
            // 禁用 CSRF(先禁用,后面讲)
            .csrf().disable();
        
        return http.build();
    }
}

配置视图控制器:

java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // 访问 /loginview 直接跳转到 login.html
        registry.addViewController("/loginview").setViewName("login");
        registry.addViewController("/").setViewName("main");
    }
}

常用登录配置项:

配置项说明
loginPage登录页地址
loginProcessingUrl登录表单提交的地址
usernameParameter用户名参数名(默认 username)
passwordParameter密码参数名(默认 password)
defaultSuccessUrl登录成功后默认跳转的页面
successForwardUrl登录成功后转发地址
failureUrl登录失败跳转地址
successHandler登录成功处理器(自定义逻辑)
failureHandler登录失败处理器(自定义逻辑)

defaultSuccessUrl vs successForwardUrl:

  • defaultSuccessUrl:重定向,如果你之前访问了某个页面被拦截了,登录成功后会回到那个页面
  • successForwardUrl:转发,不管之前访问什么,登录成功后都去这个地址

一般用 defaultSuccessUrl 体验更好。

7.4.3 测试一下 ​

启动项目,访问任意页面,会跳转到你自定义的登录页。

用 admin/123456 登录,看看效果。


7.5 从数据库查询用户(自定义 UserDetailsService) ​

内存用户只能测试用,实际项目用户都存在数据库里。

我们来实现从数据库查询用户。

7.5.1 准备数据库表 ​

用户表:

sql
CREATE TABLE `user` (
    `id` INT PRIMARY KEY AUTO_INCREMENT,
    `username` VARCHAR(50) NOT NULL UNIQUE,
    `password` VARCHAR(100) NOT NULL,
    `valid` INT DEFAULT 1 COMMENT '是否有效 1-有效 0-无效'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

权限表:

sql
CREATE TABLE `priv` (
    `id` INT PRIMARY KEY AUTO_INCREMENT,
    `authority` VARCHAR(50) NOT NULL COMMENT '权限标识'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

用户权限关联表:

sql
CREATE TABLE `user_priv` (
    `user_id` INT,
    `priv_id` INT,
    PRIMARY KEY (`user_id`, `priv_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

插入测试数据:

sql
-- 密码都是 123456 的 BCrypt 加密值
INSERT INTO `user` (username, password, valid) VALUES
('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', 1),
('user', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', 1);

INSERT INTO priv (authority) VALUES
('ROLE_ADMIN'),
('ROLE_USER'),
('book:list'),
('book:manage');

INSERT INTO user_priv (user_id, priv_id) VALUES
(1, 1),  -- admin 有 ROLE_ADMIN
(1, 3),  -- admin 有 book:list
(1, 4),  -- admin 有 book:manage
(2, 2),  -- user 有 ROLE_USER
(2, 3);  -- user 有 book:list

注意:权限如果是角色的话,要加 ROLE_ 前缀。 比如角色 ADMIN,权限标识就是 ROLE_ADMIN。

7.5.2 加依赖 ​

xml
<!-- JDBC -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<!-- MySQL 驱动 -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
</dependency>

7.5.3 配置数据库 ​

yaml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/chapter07?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

7.5.4 写实体类 ​

UserDto(用户):

java
@Data
public class UserDto {
    private Integer id;
    private String username;
    private String password;
    private Integer valid;  // 是否有效
}

Privilege(权限):

java
@Data
public class Privilege {
    private Integer id;
    private String authority;
}

7.5.5 写 DAO ​

java
@Repository
public class UserDao {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 根据用户名查询用户
     */
    public UserDto getUserByUsername(String username) {
        String sql = "SELECT * FROM user WHERE username = ?";
        List<UserDto> list = jdbcTemplate.query(sql, 
            new BeanPropertyRowMapper<>(UserDto.class), username);
        if (list != null && list.size() == 1) {
            return list.get(0);
        }
        return null;
    }

    /**
     * 根据用户ID查询权限
     */
    public List<String> findPrivilegesByUserId(Integer userId) {
        String sql = "SELECT p.authority " +
                     "FROM user u, priv p, user_priv up " +
                     "WHERE up.user_id = u.id AND up.priv_id = p.id AND u.id = ?";
        List<Privilege> list = jdbcTemplate.query(sql,
            new BeanPropertyRowMapper<>(Privilege.class), userId);
        
        List<String> privileges = new ArrayList<>();
        list.forEach(p -> privileges.add(p.getAuthority()));
        return privileges;
    }
}

7.5.6 实现 UserDetailsService ​

这是最关键的一步!实现 UserDetailsService 接口,告诉 Spring Security 怎么查用户。

java
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserDao userDao;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. 根据用户名查询用户
        UserDto userDto = userDao.getUserByUsername(username);
        
        if (userDto == null) {
            // 用户不存在,抛出异常
            throw new UsernameNotFoundException("用户不存在:" + username);
        }
        
        // 2. 根据用户ID查询权限
        List<String> privileges = userDao.findPrivilegesByUserId(userDto.getId());
        
        // 3. 转换成 UserDetails
        UserDetails userDetails = User.withUsername(userDto.getUsername())
                .password(userDto.getPassword())
                .authorities(privileges.toArray(new String[0]))  // 权限列表
                .disabled(userDto.getValid() != 1)  // 是否禁用
                .build();
        
        return userDetails;
    }
}

就这么简单!

  • 你实现 loadUserByUsername 方法
  • 传入用户名,你返回 UserDetails(里面有密码、权限等)
  • Spring Security 自动帮你做密码比对、登录处理

这就是 Spring Security 的设计:

  • 它不管你的用户存在哪里(数据库、Redis、LDAP...)
  • 你只要告诉它怎么查,它就帮你搞定认证

7.5.7 配置使用自定义 UserDetailsService ​

java
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {

    @Autowired
    private UserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .userDetailsService(userDetailsService)  // 使用自定义的 UserDetailsService
            
            .authorizeRequests()
                .antMatchers("/loginview", "/css/**", "/js/**", "/img/**").permitAll()
                .anyRequest().authenticated()
            .and()
            
            .formLogin()
                .loginPage("/loginview")
                .loginProcessingUrl("/doLogin")
                .defaultSuccessUrl("/")
            .and()
            
            .logout()
                .logoutSuccessUrl("/loginview?logout")
            .and()
            
            .csrf().disable();
        
        return http.build();
    }
}

7.5.8 测试一下 ​

启动项目,用数据库里的用户登录试试。

  • admin / 123456 → 管理员
  • user / 123456 → 普通用户

搞定!现在用户是从数据库查的了。 这是实际项目中最常用的方式。


7.6 授权控制:你能做什么 ​

认证解决了"你是谁",现在来看授权:"你能做什么"。

Spring Security 支持两种级别的授权:

  1. URL 级别授权:哪些 URL 需要什么权限
  2. 方法级别授权:哪些方法需要什么权限

7.6.1 URL 级别授权 ​

在 SecurityConfig 里配置,哪些 URL 需要什么权限。

java
http.authorizeRequests()
    // 所有人都能访问
    .antMatchers("/loginview", "/css/**", "/js/**").permitAll()
    
    // 需要 ADMIN 角色才能访问
    .antMatchers("/admin/**").hasRole("ADMIN")
    
    // 需要 book:manage 权限才能访问
    .antMatchers("/book/manage/**").hasAuthority("book:manage")
    
    // 需要同时满足多个权限
    .antMatchers("/super/**").hasAnyAuthority("a", "b", "c")
    
    // 其他的,登录就能访问
    .anyRequest().authenticated();

常用权限控制方法:

方法说明
permitAll()所有人都能访问
denyAll()所有人都不能访问
authenticated()登录后就能访问
anonymous()匿名(未登录)才能访问
rememberMe()记住我登录的能访问
fullyAuthenticated()完全认证(不是记住我)才能访问
hasRole("ADMIN")有指定角色
hasAnyRole("ADMIN", "USER")有其中一个角色
hasAuthority("book:list")有指定权限
hasAnyAuthority("a", "b")有其中一个权限
hasIpAddress("192.168.1.0/24")指定 IP 才能访问

角色 vs 权限:

  • 角色:hasRole("ADMIN") → 实际检查的是 ROLE_ADMIN
  • 权限:hasAuthority("book:list") → 检查的就是 book:list

简单理解:

  • 角色是粗粒度的:管理员、普通用户
  • 权限是细粒度的:新增、删除、修改
  • 角色本质上也是一种权限,只是加了 ROLE_ 前缀

antMatchers 的匹配规则:

  • ? 匹配一个字符
  • * 匹配 0 个或多个字符
  • ** 匹配 0 个或多个目录

例子:

  • /admin/** → 匹配 /admin/ 下的所有路径
  • /book/*.html → 匹配 /book/ 下的所有 html 文件
  • /book/? → 匹配 /book/a、/book/b 等

注意:匹配顺序是从上到下的,前面的先匹配。 所以要把具体的放前面,通用的放后面。

7.6.2 方法级别授权 ​

URL 级别授权是在配置里统一配的。 有时候你想在具体的方法上加权限控制,就用方法级别授权。

第一步:开启方法级安全

java
@Configuration
@EnableGlobalMethodSecurity(
    securedEnabled = true,   // 开启 @Secured
    prePostEnabled = true,   // 开启 @PreAuthorize / @PostAuthorize
    jsr250Enabled = true     // 开启 @RolesAllowed
)
public class SecurityConfig {
    // ...
}

第二步:在方法上加注解

@Secured:

java
@Controller
@RequestMapping("/book")
public class BookController {

    // 所有人登录后都能访问
    @RequestMapping("/list")
    public String list() {
        return "book_list";
    }

    // 需要 ROLE_ADMIN 角色才能访问
    @RequestMapping("/admin/manag")
    @Secured("ROLE_ADMIN")
    public String manag() {
        return "book_manag";
    }
}

@Secured 是 Spring Security 自带的注解。 注意:角色要写完整的 ROLE_ADMIN,不能只写 ADMIN。

@PreAuthorize:

java
// 需要 admin 角色
@PreAuthorize("hasRole('ADMIN')")
public void delete() { ... }

// 需要 book:manage 权限
@PreAuthorize("hasAuthority('book:manage')")
public void add() { ... }

// 满足任意一个
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
public void update() { ... }

// 可以用 SpEL,更灵活
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
public void getUser(Long userId) { ... }

@PreAuthorize 更强大,支持 SpEL 表达式。 角色不用写 ROLE_ 前缀,hasRole 会自动加。

@RolesAllowed:

java
@RolesAllowed("ROLE_ADMIN")
public void delete() { ... }

@RolesAllowed 是 JSR-250 标准的注解,跟 @Secured 差不多。

三种注解对比:

注解来源支持 SpEL角色前缀
@SecuredSpring Security不支持要写 ROLE_
@PreAuthorizeSpring Security支持hasRole 自动加
@RolesAllowedJSR-250 标准不支持要写 ROLE_

一般推荐用 @PreAuthorize,因为更灵活,支持 SpEL。

7.6.3 权限不足怎么办 ​

权限不足的时候,默认会返回 403 错误页面,很丑。

我们可以自定义 403 页面。

配置:

java
http.exceptionHandling()
    .accessDeniedPage("/403");  // 权限不足跳转的页面

写一个 403 页面:

html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>403 权限不足</title>
</head>
<body>
    <h1>403 - 权限不足</h1>
    <p>抱歉,您没有权限访问此页面</p>
    <a href="/">返回首页</a>
</body>
</html>

7.7 动态菜单展示:根据权限显示不同内容 ​

登录之后,不同角色的用户看到的菜单应该不一样。 比如管理员能看到"管理后台"菜单,普通用户看不到。

用 Thymeleaf + Spring Security 标签可以很方便地实现。

7.7.1 加依赖 ​

xml
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

7.7.2 常用标签 ​

在页面上引入命名空间:

html
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

常用属性:

属性作用
sec:authorize="isAuthenticated()"已登录才显示
sec:authorize="isAnonymous()"未登录才显示
sec:authorize="hasRole('ADMIN')"有指定角色才显示
sec:authorize="hasAuthority('book:manage')"有指定权限才显示
sec:authentication="name"显示用户名
sec:authentication="principal.username"显示用户名
sec:authentication="authorities"显示权限列表

7.7.3 例子:动态菜单 ​

html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
    <meta charset="UTF-8">
    <title>首页</title>
</head>
<body>
    <h1>图书管理系统</h1>
    
    <!-- 已登录才显示 -->
    <div sec:authorize="isAuthenticated()">
        <p>欢迎您,<span sec:authentication="name"></span>!</p>
        
        <ul>
            <!-- 所有人都能看到 -->
            <li><a href="/book/list">图书列表</a></li>
            
            <!-- 只有管理员能看到 -->
            <li sec:authorize="hasRole('ADMIN')">
                <a href="/book/admin/manag">图书管理</a>
            </li>
        </ul>
        
        <a href="/logout">退出登录</a>
    </div>
    
    <!-- 未登录才显示 -->
    <div sec:authorize="isAnonymous()">
        <p>您还未登录</p>
        <a href="/loginview">去登录</a>
    </div>
</body>
</html>

这样就实现了动态菜单:不同权限的用户看到的内容不一样。 非常方便!


7.8 会话管理 ​

7.8.1 Session 超时 ​

设置 Session 过期时间:

yaml
server:
  servlet:
    session:
      timeout: 30m  # 30 分钟超时

超时后,用户需要重新登录。

7.8.2 并发会话控制 ​

限制同一个用户最多能在几个地方登录:

java
http.sessionManagement()
    .maximumSessions(1)  // 最多 1 个会话(后登录的把前面的挤掉)
    .maxSessionsPreventsLogin(false)  // false=后登录挤掉前一个,true=后登录被禁止
    .expiredUrl("/loginview?expired");  // 会话过期跳转

两种策略:

  • maxSessionsPreventsLogin(false):后登录的把前面的挤掉(默认)
  • maxSessionsPreventsLogin(true):已经登录了就不让再登录了

比如 QQ 就是后登录挤掉前面的,很多系统都是这样。

7.8.3 Session 固定攻击防护 ​

什么是 Session 固定攻击?

  • 攻击者先访问网站,拿到一个 SessionID
  • 然后诱导你用这个 SessionID 登录
  • 你登录后,这个 SessionID 就变成已登录的了
  • 攻击者用同一个 SessionID 就能以你的身份访问

Spring Security 默认开启了 Session 固定攻击防护:登录成功后会换一个 SessionID。

java
http.sessionManagement()
    .sessionFixation().migrateSession();  // 默认,迁移 Session

几种策略:

  • migrateSession:迁移(默认,推荐)
  • newSession:新建一个全新的 Session
  • none:不防护(不推荐)

默认的就行,一般不用改。


7.9 退出登录 ​

退出登录很简单,Spring Security 已经帮你做好了。

7.9.1 基本配置 ​

java
http.logout()
    .logoutUrl("/logout")                    // 退出地址
    .logoutSuccessUrl("/loginview?logout")   // 退出成功跳转
    .invalidateHttpSession(true)             // 使 Session 失效(默认 true)
    .clearAuthentication(true);              // 清除认证信息(默认 true)

7.9.2 页面上放退出链接 ​

html
<a href="/logout">退出登录</a>

点击就退出了,非常简单。

注意:如果开启了 CSRF,退出必须是 POST 请求。 禁用 CSRF 的话 GET 也行。 后面讲 CSRF 的时候会细说。


7.10 CSRF 防护 ​

7.10.1 什么是 CSRF ​

CSRF(Cross-Site Request Forgery)跨站请求伪造。

简单说:

  • 你登录了 A 网站,Cookie 里有 SessionID
  • 你又访问了恶意网站 B
  • 网站 B 偷偷发请求给 A 网站(比如转账、改密码)
  • 因为你的浏览器带着 A 网站的 Cookie,A 网站以为是你在操作
  • 于是就中招了

7.10.2 Spring Security 的 CSRF 防护 ​

Spring Security 默认开启了 CSRF 防护。

原理:

  • 每个表单都带一个 CSRF Token
  • 提交的时候验证 Token
  • Token 是随机的,攻击者拿不到

Thymeleaf 里自动加 CSRF Token:

Thymeleaf 的 form 标签会自动加上 CSRF Token,不用你管。

html
<form action="/doLogin" method="post">
    <!-- 自动会加一个隐藏的 _csrf 字段 -->
    <input type="text" name="username">
    <input type="password" name="password">
    <button type="submit">登录</button>
</form>

7.10.3 什么时候禁用 CSRF ​

前后端分离的项目,一般会禁用 CSRF:

  • 前后端分离用 Token(JWT)认证,不用 Cookie
  • CSRF 攻击的前提是 Cookie 自动携带
  • 不用 Cookie 的话,CSRF 就不存在了

禁用方式:

java
http.csrf().disable();

我们前面的例子都禁用了 CSRF,是为了方便测试。 传统的服务端渲染项目(Thymeleaf 这种),建议开启 CSRF。 前后端分离项目,一般用 JWT,可以禁用。


7.11 记住我(Remember Me) ​

用户勾选"记住我",关闭浏览器再打开,还是登录状态。

7.11.1 简单版(内存) ​

java
http.rememberMe()
    .rememberMeParameter("remember-me")  // 表单参数名
    .tokenValiditySeconds(7 * 24 * 60 * 60);  // 有效期 7 天

表单上加一个复选框:

html
<input type="checkbox" name="remember-me"> 记住我

简单版的 Token 存在内存里,重启就没了。 适合开发测试。

7.11.2 持久化版(数据库) ​

生产环境用持久化的,Token 存在数据库里。

建表:

sql
CREATE TABLE persistent_logins (
    username VARCHAR(64) NOT NULL,
    series VARCHAR(64) PRIMARY KEY,
    token VARCHAR(64) NOT NULL,
    last_used TIMESTAMP NOT NULL
);

配置:

java
@Autowired
private DataSource dataSource;

@Bean
public PersistentTokenRepository persistentTokenRepository() {
    JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
    tokenRepository.setDataSource(dataSource);
    // tokenRepository.setCreateTableOnStartup(true);  // 自动建表(第一次用)
    return tokenRepository;
}

http.rememberMe()
    .tokenRepository(persistentTokenRepository())
    .tokenValiditySeconds(7 * 24 * 60 * 60);

记住我功能很实用,用户体验好。 但要注意安全,敏感操作还是要重新验证密码。


7.12 获取当前登录用户 ​

很多时候我们需要获取当前登录用户的信息。

7.12.1 从 SecurityContextHolder 获取 ​

java
@RestController
public class UserController {

    @RequestMapping("/getUsername")
    public String getUsername() {
        // 获取认证信息
        Authentication authentication = 
            SecurityContextHolder.getContext().getAuthentication();
        
        if (!authentication.isAuthenticated()) {
            return "未登录";
        }
        
        // 获取用户详情
        UserDetails userDetails = 
            (UserDetails) authentication.getPrincipal();
        
        return userDetails.getUsername();
    }
}

7.12.2 方法参数注入 ​

java
@RequestMapping("/info")
public String info(Authentication authentication) {
    return authentication.getName();
}

// 或者
@RequestMapping("/info2")
public String info2(@AuthenticationPrincipal UserDetails userDetails) {
    return userDetails.getUsername();
}

直接在方法参数上加,Spring 自动注入。

7.12.3 Thymeleaf 页面上获取 ​

前面讲过,用 sec 标签:

html
<span sec:authentication="name"></span>

7.13 企业最佳实践 ​

7.13.1 密码安全 ​

  • 一定要加密:用 BCrypt,不要明文,不要用 MD5
  • 密码强度:要求用户设置复杂密码
  • 加密强度:BCrypt 的 strength 可以调,默认 10 就够了
  • 定期改密码:敏感系统要求定期修改密码

7.13.2 权限设计 ​

RBAC 模型(Role-Based Access Control):

用户 → 角色 → 权限
  • 用户有角色(比如:管理员、普通用户)
  • 角色有权限(比如:新增、删除、修改)
  • 用户通过角色获得权限

这是最常用的权限设计模型。

更复杂的还有:

  • 数据权限(只能看自己的数据)
  • 部门权限
  • 动态权限(权限存在数据库,动态配置)

刚开始学,先把 RBAC 搞明白就够了。

7.13.3 前后端分离的认证 ​

传统项目用 Session + Cookie。 前后端分离项目,一般用 JWT(JSON Web Token)。

JWT 的流程:

  1. 用户登录,成功后返回一个 Token
  2. 前端把 Token 存起来(localStorage)
  3. 每次请求都带上 Token(放在 Header 里)
  4. 后端验证 Token

好处:

  • 无状态,服务端不用存 Session
  • 分布式系统友好
  • 支持跨域

JWT 现在非常流行,前后端分离项目基本都用。 Spring Security 也支持 JWT,但需要自己配置一下。 这一章没讲,有兴趣可以自己去了解。

7.13.4 安全防护 ​

  • CSRF 防护:传统项目要开,前后端分离用 JWT 可以关
  • XSS 防护:前端转义,后端也要校验
  • SQL 注入:用参数化查询,不要拼接 SQL
  • 暴力破解防护:登录失败次数限制、验证码
  • HTTPS:生产环境一定要用 HTTPS

7.13.5 日志与审计 ​

  • 记录登录日志(谁、什么时候、从哪登录的)
  • 记录敏感操作日志(谁、什么时候、做了什么)
  • 出问题能追溯

7.14 新手常见问题排查 ​

问题 1:加了 Spring Security 依赖,所有页面都要登录 ​

原因:Spring Security 默认保护所有 URL

解决:在配置里配置哪些 URL 不需要登录(permitAll)

问题 2:登录一直失败,密码明明是对的 ​

可能原因:

  1. 密码没加密,或者加密方式不对
  2. UserDetailsService 没配置对
  3. 用户名参数名不对
  4. 密码格式不对

排查:

  • 检查 PasswordEncoder 配置
  • 检查数据库里的密码是不是 BCrypt 加密的
  • 打印一下 UserDetailsService 返回的用户信息
  • 检查表单的 name 属性是不是 username/password

问题 3:权限不够,403 错误 ​

可能原因:

  1. 角色没加 ROLE_ 前缀
  2. 权限标识写错了
  3. @Secured 里没写 ROLE_
  4. 方法级安全没开启

排查:

  • 检查数据库里的权限是不是 ROLE_ 开头
  • 检查 @EnableGlobalMethodSecurity 有没有开
  • @Secured 要写完整的 ROLE_ADMIN
  • @PreAuthorize 的 hasRole 不用写 ROLE_

问题 4:自定义登录页样式不对 ​

原因:静态资源被拦截了

解决:把静态资源路径加入 permitAll

java
.antMatchers("/css/**", "/js/**", "/img/**").permitAll()

问题 5:退出登录不好使 ​

可能原因:

  1. CSRF 开启了,退出用的 GET 请求
  2. 退出地址不对

解决:

  • 禁用 CSRF,或者退出用 POST 请求
  • 检查 logoutUrl 配置

问题 6:获取不到当前用户 ​

可能原因:

  1. 没登录
  2. 类型转换错了
  3. SecurityContext 没拿到

排查:

  • 先判断 isAuthenticated()
  • 确认 principal 的类型(一般是 UserDetails)
  • 检查是不是在异步线程里(异步线程拿不到,需要特殊处理)

7.15 本章小结 ​

恭喜你!Spring Security 这一章学完了!

这一章内容比较多,概念也比较多,刚开始可能觉得有点乱。 没关系,先把核心的搞明白,其他的用到再查。

你都学了什么 ​

安全管理概述:

  • 为什么需要安全管理
  • 认证 vs 授权
  • 常见安全框架
  • Spring Security 简介

快速入门:

  • 引入依赖后的默认效果
  • 内存用户配置
  • 密码加密(BCrypt)

核心概念:

  • UserDetails:用户信息
  • UserDetailsService:加载用户
  • PasswordEncoder:密码加密
  • SecurityContextHolder:获取当前用户

自定义登录:

  • 自定义登录页面
  • 登录配置详解
  • 自定义 UserDetailsService(从数据库查用户)

授权控制:

  • URL 级别授权(antMatchers)
  • 方法级别授权(@Secured、@PreAuthorize、@RolesAllowed)
  • 权限不足处理

动态菜单:

  • Thymeleaf Spring Security 标签
  • 根据权限显示不同内容

会话管理:

  • Session 超时
  • 并发会话控制
  • Session 固定攻击防护

其他功能:

  • 退出登录
  • CSRF 防护
  • 记住我(Remember Me)
  • 获取当前登录用户

企业最佳实践:

  • 密码安全
  • RBAC 权限模型
  • 前后端分离与 JWT
  • 安全防护
  • 日志审计

动手实践清单 ​

实践项做完打勾
能搭建 Spring Security 项目,看到默认登录页☐
会配置内存用户☐
会配置密码加密(BCrypt)☐
能自定义登录页面☐
能实现自定义 UserDetailsService(从数据库查)☐
会配置 URL 级别授权☐
会用方法级别授权(@Secured / @PreAuthorize)☐
能实现动态菜单(Thymeleaf sec 标签)☐
会配置退出登录☐
知道什么是 CSRF,什么时候开什么时候关☐
会配置记住我☐
能获取当前登录用户信息☐
理解认证和授权的区别☐
知道 RBAC 权限模型☐

面试常问 ​

  1. Spring Security 的认证流程?

    • 用户提交用户名密码
    • UsernamePasswordAuthenticationFilter 拦截
    • 调用 UserDetailsService 加载用户
    • PasswordEncoder 比对密码
    • 认证成功,把用户信息放到 SecurityContext
    • 认证失败,跳转到失败页
  2. Spring Security 的核心过滤器有哪些?

    • UsernamePasswordAuthenticationFilter:用户名密码认证
    • FilterSecurityInterceptor:授权过滤
    • CsrfFilter:CSRF 防护
    • RememberMeAuthenticationFilter:记住我
    • LogoutFilter:退出
    • ...还有很多
  3. UserDetailsService 和 UserDetails 的区别?

    • UserDetails:用户信息接口,定义了用户的属性(用户名、密码、权限等)
    • UserDetailsService:加载用户的接口,根据用户名查询用户,返回 UserDetails
  4. Spring Security 怎么实现授权?

    • URL 级别:在配置里用 antMatchers 配置
    • 方法级别:用 @Secured、@PreAuthorize 等注解
    • 底层是通过 FilterSecurityInterceptor 实现的
  5. BCrypt 加密的原理?为什么安全?

    • 加盐:每个密码都有随机盐
    • 慢哈希:计算慢,抗暴力破解
    • 不可逆:不能解密
    • 同一个密码每次加密结果不一样
  6. 什么是 CSRF?怎么防护?

    • 跨站请求伪造,利用 Cookie 自动携带的特性
    • 防护:CSRF Token,每次请求带 Token 验证
    • 前后端分离用 JWT 的话,可以不用 CSRF
  7. Session 共享怎么实现?

    • Spring Session + Redis
    • 把 Session 存在 Redis 里,多个实例共享
    • 分布式系统必备
  8. JWT 了解吗?和 Session 有什么区别?

    • JWT 是无状态的,服务端不用存
    • Session 是有状态的,服务端要存
    • JWT 适合前后端分离、分布式系统
    • JWT 缺点:不能主动失效,占带宽

给实习生的建议 ​

  1. 先把核心的搞明白:认证、授权、UserDetailsService 这几个是基础,一定要搞懂。

  2. 多动手:Spring Security 光看没用,一定要自己写代码、跑起来、调通。

  3. 不用追求全都会:Spring Security 功能非常多,不可能一下子全学会。先掌握常用的,其他的用到再查。

  4. 理解设计思想:Spring Security 的设计很优秀,理解了它的设计思想,学其他安全框架也快。

  5. 安全无小事:生产环境的安全配置一定要谨慎,密码要加密、敏感操作要验证、漏洞要及时修。

  6. 前后端分离要学 JWT:现在很多项目都是前后端分离,JWT 是标配,有时间一定要学。

下一章我们学习消息服务(RabbitMQ)。加油!


基于 Vite 强力驱动 | 纯静态轻量托管